在繼續深談 React 管理並更新畫面的策略與機制之前,我們先來探究一下關於單向資料流的概念,以及在尚未使用前端框架時實現單向資料流的 DOM 渲染策略,來幫助我們了解「沒有使用前端框架來管理畫面時,會遇到的問題與需求」,進而更好地理解為什麼 React 可以幫助我們解決這些問題。
我們先聚焦在一個相當重要的 design pattern 上 —— 單向資料流。單向資料流是目前在前端領域中相當主流且被普遍應用的 pattern,當今最熱門的前端框架或解決方案基本上都是遵循這個 pattern 所設計的。
任何 UI 畫面只要不是完全靜態寫死的,則背後一定有其作為來源的原始資料,例如購物網站的商品列表、社群網站的動態內容、論壇中的文章列表…等等。而使用者最後看到的光鮮亮麗且內容豐富的 UI 畫面是怎麼產生的呢?其實就是如上圖所示意的:當我們獲得這些新的原始資料時,將這些資料套入預先定義好的模板以及渲染邏輯,進而產生使用者所看到的畫面。
而單向資料流的核心概念就是:畫面結果是原始資料透過模板與渲染邏輯所產生的延伸結果,而這個過程是單向且不可逆的。當資料發生變化時,畫面才會產生對應的變化,以資料去驅動畫面。
所謂「單向」的意思,就是只有資料變化時才能導致畫面更新,畫面無法在原始資料發生變化以外的情況隨意改變。且畫面本身也不允許以任何原因,主動逆向去直接修改原始資料。
由於這是一個單向的流程,因此畫面不會因為資料變化以外的任何原因而隨意改變,這樣就可以保證將 UI 產生的主要變因限縮在「資料」上,並且當資料更新時對應綁定的畫面就會自動發生變化,進而提升前端應用程式的可靠性與可維護性。
在單向資料流的概念中,畫面是資料延伸的結果。為了將這個概念在前端瀏覽器的 UI 管理中實際應用,我們會把資料以及畫面分離,當資料改變完成之後,再執行對應的畫面變更(DOM 操作)。而我們通常會有兩種更新並渲染畫面的策略:
舉例來說,我們今天有一個 counter list,資料是一個存放了所有 counter value 的數字陣列,並且畫面會印出所有 counter 目前的值,以及所有 counter 加總之後的值。當按下 increment button 時,資料陣列中 index 0
與 2
的 counter 值都會各 +1 ,並更新畫面:
const counterValues = [0, 0, 0];
function getNumbersSum(numbers) {
return numbers.reduce((x, y) => x + y);
}
function incrementCounterAndUpdateDOM(index) {
counterValues[index] += 1;
// 資料更新後,需要具體知道這次資料的更新會影響到的 DOM 範圍,並且手動一一去更新:
// 修改某個 counter 的 value 資料後,
// 該 counter 對應的 <li> 裡面的 <span> 的文字內容會需要更新
const counterValueLabelElement = document
.querySelectorAll('#counter-list > li > span')
.item(index);
counterValueLabelElement.textContent = counterValues[index];
// 修改某個 counter value 資料後,也會需要重新計算並更新 counter sum 的文字內容
const counterSumValueLabelElement = document.querySelector('#counter-sum > span');
counterSumValueLabelElement.textContent = getNumbersSum(counterValues);
}
function initialRender() {
// 只有初始化 render 時才會遍歷整個 counterValues 來印出每個 counter item
document.body.innerHTML = `
<div id="counters-wrapper">
<ul id="counter-list">
${counterValues.map((counterValue, index) => `
<li>counter ${index}: <span>${counterValue}</span></li>
`).join('')}
</ul>
<div id="counter-sum">
counters sum: <span>${getNumbersSum(counterValues)}</span>
</div>
</div>
<button id="increment-btn">increment counter 0 & 2</button>
`;
// increment button 事件綁定
const incrementButton = document.getElementById('increment-btn');
incrementButton.addEventListener('click', () => {
// 範例行為:increment counter 0 & counter 2
incrementCounterAndUpdateDOM(0);
incrementCounterAndUpdateDOM(2);
});
}
initialRender();
可以看到在以上的範例中,當我們更新資料 counterValues[index] += 1
之後,會人為判斷並手動去尋找對應會影響到的 DOM elements counterValueLabelElement
& counterSumValueLabelElement
,然後一一手動替換它們的內容。
這個過程中,更新資料中的哪個部分會導致哪些 DOM elements 需要連動更新,以及如何操作 DOM 的具體細節,都是需要完全依賴開發者的人腦自己去判斷以及手動操作細節的。
這種畫面渲染策略的好處是只要開發者 DOM 操作的夠簡潔精準,只操作真正有需要更新的部分 DOM,不需要更新的部分就不去動的話,就可以盡量減少因為多餘 DOM 操作而帶來的效能浪費。例如從上面的範例結果圖中可以看到:當我們點擊 increment counter button 時,只有 counter 0 與 counter 2 的 span
,以及 counter sum 的 span
才被修改,其他的 DOM elements 都完全沒有被動到。
然而,當該資料的變化同時需要連動更新畫面的地方相當多或很複雜時,純靠人為的維護就非常容易有所遺漏或出錯。並且當畫面結果有問題時,我們也很難在開發上快速定位是哪個環節出了差錯,因為即使資料本身沒問題,也可能因為對應的 DOM 操作出錯,而導致最後畫面結果仍是錯的,此時單向資料所遵循的「畫面是資料的延伸結果」就已經不再保證可靠了。
因此,這種渲染策略下的單向資料流,可以說是完全依賴人為周全的判斷以及精確的手動操作 DOM 來維持的,在大型且複雜的前端應用程式中就顯得非常脆弱且不可靠。
承策略一的相同例子,但這次我們改成當資料變更時一律重繪畫面的渲染策略:
const counterValues = [0, 0, 0];
function getNumbersSum(numbers) {
return numbers.reduce((x, y) => x + y);
}
function renderCounterListAndSum() {
const countersWrapperElement = document.getElementById('counters-wrapper');
// 先將 counters wrapper 內的整塊 DOM elements 全部清空
countersWrapperElement.innerHTML = '';
// 根據最新的 counterValues 資料,
// 重繪所有的 counter item 的 DOM elements 以及 counter sum
countersWrapperElement.innerHTML = `
<ul id="counter-list">
${counterValues.map((counterValue, index) => `
<li>counter ${index}: <span>${counterValue}</span></li>
`).join('')}
</ul>
<div id="counter-sum">
counters sum: <span>${getNumbersSum(counterValues)}</span>
</div>
`;
}
const handleIncrementButtonClick = () => {
// 範例行為:increment counter 0 & counter 2
counterValues[0] += 1;
counterValues[2] += 1;
// 在更新資料後,不需要具體知道這次資料更新應連動影響到的 DOM elements 有哪些,
// 一律直接呼叫 renderCounterListAndSum() 來將畫面重繪
renderCounterListAndSum();
};
function initialRender() {
document.body.innerHTML = `
<div id="counters-wrapper"></div>
<button id="increment-btn">increment counter 0 & 2</button>
`;
// render 初始資料狀態的 counter list & sum
renderCounterListAndSum();
// increment button 事件綁定
const incrementButtonElement = document.getElementById('increment-btn');
incrementButtonElement.addEventListener('click', handleIncrementButtonClick);
}
initialRender();
可以看到在以上的範例中,當我們更新資料 counterValues[index] += 1
之後,完全不需要具體知道這次資料更新後應受到連動更新的 DOM elements 有哪些,而是一律直接呼叫 renderCounterListAndSum()
,來將畫面的 DOM 清空之後再根據最新的完整資料將畫面完整重繪一次。
這個過程中,無論今天資料發生的更新是新增項目,修改項目,還是刪除項目,都完全不用管,我們只需要在更新好原始資料後非常無腦的把畫面清空再全部重繪就好。因此開發者既不需要區分資料是發生哪種變更,也不用關心這種資料變更後會影響哪部分的 DOM elements,更不需要自己動手去尋找並操作特定的 DOM elements。
因此在這種渲染策略下我們只需要將資料以及渲染模板定義好,然後當每次資料發生任何變更時都一律清空畫面再重繪,便可以輕鬆的維持穩定可靠的單向資料流。
然而這種渲染策略也有著難以忽略的明顯缺點,就是效能浪費。
在上面的範例結果圖中可以看到,每次當我們點擊 increment button 來觸發資料 counter value 改變時,整個 counters-wrapper
內的 DOM elements(包含原有的 counter-list
以及 counter-sum
)都會全部被刪除再全部重繪,無論它們是否都有被修改的必要。在本範例中的 DOM elements 相當簡單且量少因此可能還感覺不太出來,但商業實務上的前端應用程式的複雜度以及資料量都是龐大許多的,在大量的資料以及使用者頻繁的操作之下效能問題就非常容易顯現出來,進而嚴重拖累使用者的體驗。
在瞭解了這兩種常見的畫面渲染策略後,你會發現無論選擇其中的哪一種,都有著明顯且難以解決的缺點。然而處理「資料改變後與畫面的連動更新」,又是前端應用程式中極其重要且難以避免的大問題。其實這就是為什麼我們會需要使用前端框架的其中一個重要原因:大多數前端框架都能透過一些特殊的抽象架構設計來幫助我們解決這個問題,可以保留這些渲染策略的優點的同時解決其缺點。
例如 Vue.js 就是採用了上面介紹的策略一,然後再透過抽象架構設計去解決需要人工判斷並手動維護 DOM 細節的缺點:
Vue.js 會分析資料與模板中的綁定關係,然後監聽資料的具體變化,偵測資料的變化類型並自動修改對應的 DOM elements,讓這一段原本最痛苦的手動維護 DOM 全部都改由框架自動幫你代勞。開發者需要專注的只有定義好資料與模板綁定,然後使用 Vue.js 所規定的方法來操作資料即可(因為這樣才監聽的到資料哪裡有變)。
而我們的主角 — React,則是採用了策略二,並且透過架構設計來解決其效能問題的缺點。
接下來的章節就讓我們回到 React,瞭解它是如何處理「資料改變後與畫面的連動更新」的問題吧。
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》
目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:
天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695
博客來(平裝版):
https://www.books.com.tw/products/0010982322
momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845
好奇問Zet大一個可能與文章不相關的問題,因為在文末有提到vuejs,好奇問一下由於vuejs在台灣市場的使用率好像上升的很快,有說法是在將來會有取代React趨勢(雖然有誇張)但身為一個react初學者開發者乍聽下會有些怕怕的,想請問Zet大在你看來是否在台灣市場有這樣的趨勢,謝謝!
Vue.js 在台灣漸漸開始普及我理解的原因有兩個,一個是它確實上手的門檻比其他主流技術選擇要更低,官方給予的周邊工具支持也很完善友好,另外一個原因則是台灣的 Vue 社群發展的蠻活躍的,也帶動更多的開發者與公司有意願投入採用。
不過我自己是認為短期內要在台灣甚至是全球在實務的商業場景上採用率超過 React 都是比較困難的,以前端技術的思想發展來說 React 一直都是比較有前瞻性的,在實務上也普遍是最為主流的選擇。不過 Vue 以開發體驗為優先的方向也讓 React 社群有所借鏡,我認為它們之間已經比較像是良性競爭的關係,以著重的方面來說各有市場並不衝突。
另外這種前端框架或技術在一些核心概念或是設計上或多或少都會有類似的地方,只要你是真的學習到其概念精髓,而不是只會調用 API 的表面皮毛,即使要轉換選擇也都不會是太大的障礙,因此我不覺得會有選錯了而浪費成本的問題。而且實際上也不會真的有一項技術是永垂不朽的,將其中的思想與觀念內化成你的技術思維才是真正能持續帶著走的東西。